[compiler] migrate from testng to munit#15356
Draft
patrick-schultz wants to merge 1 commit intohail-is:mainfrom
Draft
[compiler] migrate from testng to munit#15356patrick-schultz wants to merge 1 commit intohail-is:mainfrom
patrick-schultz wants to merge 1 commit intohail-is:mainfrom
Conversation
6c56fb2 to
3efb450
Compare
3efb450 to
e884405
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Change Description
Migrate all of our scala based tests from the TestNG test library and runner, to MUnit.
This was a big change, and was primarily carried out by Claude Code. I iterated on one file at a time at first, building up a document containing all details on how to migrate a test file. When I was convinced there weren't many surprising situations remaining to be discovered, I ran a bash script looping over all remaining test files, running a fresh claude code instance with the migration plan doc on each. Then I scanned over all changes, and made a few tweaks to the migration doc and had claude help me apply the new rules consistently.
I've spent enough time looking over the migrated tests to have high confidence that they haven't functionally changed. To review these changes, I recommend reading the final migration plan, copied below, and then spot checking the changes to see how the plan was applied in practice.
Benefits
MUnit is now the test framework recommended by the official Scala docs. It is much simpler than ScalaTest, and much more idiomatic than TestNG. I've found it easy to use, and to hack on when needed. For instance, there isn't any built in support for "test matrices" - multiple test bodies, each of which are run against a list of multiple test inputs. But I added a simple way to encode these in
TestCaseSupport. It's a welcome relief after TestNG'sArray[Array[Any]]data providers.The support in Mill also seems to be better. And the test output is far more readable.
Unrelated bugs caught
During one of the migrations, claude also caught a pre-existing bug:
SimplifySuite.testBinaryFloatingSimplificationused wrong DataProvider: The@Testannotation referenceddataProvider = "binaryIntegralArithmetic"instead of"floatingIntegralArithmetic". This caused the floating-point simplification test to run with the integral arithmetic test data, while the actual floating-point data (defined inbinaryFloatingArithmeticwith@DataProvider(name = "floatingIntegralArithmetic")) was never exercised. Fixed by wiring the floating test cases to their ownTestCasesobject.ASM4SSuite.{nanDoubleAlwaysComparesFalse, nanFloatAlwaysComparesFalse}didn't test anything, and what they tried to assert was wrong.NaN != xis always true. The idiomatic syntax for scalacheck tests in MUnit makes it much harder to accidentally not test anything like this.ReferenceGenomeSuite.Fasta: was callingProp.check(), whose doc string says "Convenience method that checks this property and reports the result on the console. Should only be used when running tests interactively within the Scala REPL". :face_palm:. One of the generators was making an interval with a null endpoint, which crashed the test. Fixed by using a non-missing generator.LocalLDPruneSuite.testRandom: was callingProperties.check(), which like the previous one doesn't actually assert anything. Contained a bug where we were comparing two arrays using==. Fixed.Running tests
Running tests looks much as it did before, but running individual test cases from the command line is much simpler now:
./mill hail[].test- run all tests./mill hail[].test.testOnly '*.FooSuite'- only tests inFooSuite./mill hail[].test.testOnly '*.FooSuite' -- '*.test foo'- only "test foo"*./mill hail[].test '*.FooSuite.test foo'- equivalent to above, however mill will run all test classes before discovering that only one contains a matching test, so the above is a bit fasterAnother option to run a single test is to annotate the test in the source, from
test("test foo") {...}totest("test foo".only) {...}, then run that test suite using./mill hail[].test.testOnly '*.FooSuite'.IntelliJ integration is also working flawlessly for me.
Security Assessment
The rest of this description is the migration plan doc I provided to claude for each file.
Migrating Hail Test Files from TestNG to MUnit
Overview
There is a single test module (
object testinhail/package.mill, usesmill.scalalib.TestModule.Munit). All test sources live inhail/test/src/. Suites are migrated in-place one at a time by switching fromHailSuitetoMUnitHailSuite(or fromTestNGSuitetomunit.FunSuitefor suites that don't needctx/fs).Plan for Migrating One Test File
Phase 1: Scaffold
MUnitHailSuiteormunit.FunSuite(see Class Declaration).@BeforeMethod→beforeEach(see@BeforeMethod→beforeEach).Phase 2: Convert Tests
Apply the conversion patterns below in order — each builds on the previous:
@Test defmethods →test()blocks (see Regular Tests).SkipException→assume(seeSkipException→assume).@DataProvider+@Test(dataProvider=...)→object Foo extends TestCases(see Static DataProviders).@DataProvider(needsctx/fs) →TestCasesobject or local helperdefwith by-name parameters; move setup side-effects tobeforeAll()(see Dynamic DataProviders).forAll→.foreach(see forAll).property()withmunit-scalacheck(see ScalaCheck Property Tests).should,shouldBe, etc.) to plain MUnit assertions (see Scalatest Matchers).assert(x == y)→assertEquals(x, y)and split conjunctive asserts (see Assertions).Phase 3: Verify
./mill 'hail[].test.compile'./mill 'hail[].test.testOnly' 'is.hail.package.SuiteName'Key Files
hail/test/src/is/hail/HailSuite.scala— the old TestNG/ScalaTest base class (unmigrated suites still extend this)hail/test/src/is/hail/MUnitHailSuite.scala— the new MUnit base class (migrated suites that needctx/fsextend this)hail/test/src/is/hail/TestCaseSupport.scala— standalone trait providing theTestCasesinner trait; can be mixed into anymunit.FunSuitehail/test/src/is/hail/ExecStrategy.scala— theExecStrategyenumerationhail/test/src/is/hail/expr/ir/TestUtils.scala— IR helper object withIRArray,IRStream,IRDict, etc.hail/test/src/is/hail/scalacheck/— the fullis.hail.scalacheckpackage (genLocus,genNullable,partition, etc.)Base Class: MUnitHailSuite
Replaces
HailSuite(which extendsTestNGSuite with TestUtils). Key differences:beforeAll()/afterAll()instead of TestNG's@BeforeClass/@AfterClass@BeforeSuite/@AfterSuite)Unitinstead of scalatest'sAssertion.succeed→()FunSuiteprovidesassert(),assertEquals(),intercept[E]()TestCasestrait: Provided via theTestCaseSupportmixin (see below)Suites That Don't Need
ctx/fsSome suites extend
TestNGSuitedirectly (notHailSuite) because they don't needctx,fs, or the Spark backend. For these, extendmunit.FunSuitedirectly instead ofMUnitHailSuite:If the suite also needs parameterized tests (
TestCases), mix inTestCaseSupport:TestCaseSupportis a standalone trait (hail/test/src/is/hail/TestCaseSupport.scala) that provides theTestCasesinner trait.MUnitHailSuitealready mixes it in, so suites extendingMUnitHailSuitegetTestCasesautomatically.Conversion Patterns
1. Class Declaration
2. Imports
Remove:
org.testng.annotations.{DataProvider, Test}org.scalatest.Inspectors.forAllorg.scalatest.enablers.InspectorAssertingorg.scalatestplus.scalacheck.CheckerAsserting.assertingNatureOfAssertionorg.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecksorg.scalatestplus.testng.TestNGSuiteChange:
import is.hail.{ExecStrategy, HailSuite}→import is.hail.{ExecStrategy, MUnitHailSuite}3.
@BeforeMethod→beforeEachTestNG's
@BeforeMethodruns before each test method. Convert to MUnit'sbeforeEach:If the old method takes a TestNG
ITestContextparameter which is actually used, ask the user for guidance on how to handle it. If it isn't used, drop it — it has no MUnit equivalent.4. Regular
@TestMethods →test()BlocksStrip the
testprefix from method names for the test label (e.g.,testFoo→"Foo"). Prefer to keep short tests on a single line.Disabled Tests (
enabled = false)MUnit's
.ignoretag preserves the "disabled" semantics — the test is reported as ignored rather than running it.5.
SkipException→assumeTestNG's
SkipExceptionis replaced by MUnit'sassume(condition, clue), which skips when the condition is false. This works both at suite level (inbeforeAll(), skipping all tests) and inside individualtest()blocks (skipping just that test).Suite-level skip (
beforeAll)Single-test skip (inside
test())Note the condition is inverted —
SkipExceptionis thrown when the condition to skip is true, whileassumeskips when the condition is false. Also remove theorg.testng.SkipExceptionimport.6.
@DataProvider— Static Data (noctx/fsneeded)Use the
TestCasesobject pattern:TestCasesis a trait defined inMUnitHailSuite:Its
test()method auto-increments a counter, producing names like"MyThing case 1","MyThing case 2", etc.Key points:
object(notclass) so there's exactly one instance per test suiteapplytakes fully typed parameters — noArray[Any]or.asInstanceOfcastingimplicit loc: munit.Locationis required so MUnit reports failures at the correct call-site lineShared DataProviders (one data set, multiple tests)
When a single
@DataProvideris referenced by multiple@Testmethods, extract the data as a sharedvalat class scope and use.foreachto register eachTestCasesobject's cases:This avoids duplicating the test cases for each test and keeps the data in one place, mirroring the original
@DataProvidersharing.7.
@DataProvider— Dynamic Data (needsctx/fs)For DataProviders whose cases reference values only available at test execution time (e.g.,
ctx,fs, or anything derived from them), the tests must still be registered at class construction time, but argument evaluation must be deferred until the test runs (afterbeforeAll()sets upctx).Two techniques work — choose based on how much shared state the cases need:
7a.
TestCasesobject + block withlazy val(preferred when cases share setup)When many cases share expensive-to-build values (e.g., a
readthat many IRs derive from), wrap them inlazy valinside a block. Thelazy valis captured by the by-name closure and only forced when the first test that touches it actually runs.The
objectlives at class scope; the block{ ... }scopes the sharedlazy vals. Each call's argument is by-name (ir: => TableIR), soreadis not forced at registration time.When to use
lazy valvs plainvalinside the block:lazy val— anything that transitively touchesctx,fs, or other test-execution-time stateval— pure IR constructors likeTrue(),I32(5),ApplyAggOp(Collect())(I32(0))7b. Local helper
def(for one-off groups or whenTestCasesoverhead isn't worth it){ val b = True() def testParseIR(name: String, ir: => IR, refMap: BindingEnv[Type] = BindingEnv.empty): Unit = { test(s"$name construction") { ir: Unit } test(s"$name parsing") { val ir0 = ir // evaluate once — re-evaluating would create new freshNames val s = Pretty.sexprStyle(ir0, elideLiterals = false) val x2 = IRParser.parse_value_ir(ctx, s, refMap) assertEquals(x2, ir0) } } testParseIR("I32", I32(5)) testParseIR("NA", NA(TInt32)) // ... one call per case }Setup side-effects
If the old
@DataProvidermethod contains side-effects (e.g.,CompileAndEvaluateto index a bgen file), move them tooverride def beforeAll()so they run once before any test body. Don't leave them inside the block or theTestCasesobject.8.
forAll→.foreachScalaTest's
forAll(fromInspectors) is just a fancy foreach:Watch for two
forAllsyntax variants:forAll(expr) { ... }— straightforward, replace withexpr.foreach { ... }forAll { Array(...) } { ... }— curly-brace variant, replace withArray(...).foreach { ... }9. ScalaCheck Property Tests (
munit-scalacheck)The test module has
munit-scalacheckas a dependency. For tests that use ScalaCheck'sforAllfor property-based testing (as opposed to ScalaTest'sInspectors.forAll), useproperty()instead oftest():Key points:
munit.ScalaCheckSuiteinstead ofmunit.FunSuiteproperty("name") = forAll(...) { ... }— note the=, no braces wrapping the body, and no.check()Boolean/Propor use MUnit assertions (assert,assertEquals, etc.) —munit.ScalaCheckSuiteprovides an implicitUnit => Propconversion, so ending aforAllbody with an assertion (which returnsUnit) works directlytest()in the same suiteMUnitHailSuite(forctx/fs) and scalacheck properties, extend both:class FooSuite extends MUnitHailSuite with munit.ScalaCheckSuiteproperty()syntaxAlways use
property()for ScalaCheck-based tests — never usetest()with.check(). This ensures the test framework controls generator seeding and surfaces the failing seed on test failure.There are two syntax variants depending on whether the
forAllis at the top of the body:=syntax — whenforAllis the entire test body (no setup before it):Block syntax — when there's setup code before the
forAll:Multiple
forAllcalls — combine them into a singlePropusing++:The
Unit => Propimplicit frommunit.ScalaCheckSuiteis in scope for all of these, so theforAllbody can end with an assertion — no need to addtrue.Remove these imports:
org.scalatestplus.scalacheck.CheckerAsserting.assertingNatureOfAssertionorg.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks10. Scalatest Matchers
Some suites use scalatest matchers (
should,shouldBe,should not be, customMatcher/MatcherFactory1, etc.) and related imports (org.scalactic,org.scalatest.matchers). These must be rewritten to use plain MUnit assertions (assert,assertEquals,intercept, etc.) or simple helper methods. The specific rewrite depends on what the matcher does — there are too many patterns to enumerate, but they should be straightforward to figure out case by case.Remove all scalatest matcher imports, e.g.:
org.scalactic.{Equivalence, Prettifier}org.scalatest.matchers._org.scalatest.matchers.should.Matchers._org.scalatest.matchers.must.Matchers._org.scalatest.matchers.dsl._11. Assertions:
assertEqualsand Splitting ConjunctionsPrefer
assertEqualsoverassert(x == y). MUnit'sassertEqualsgives a structured diff on failure instead of just "assertion failed":Split conjunctive
assertcalls into separate assertions so that failures pinpoint exactly which condition failed:Similarly, break up
assert(x.forall { ... })over small collections into.foreachwith individual asserts when the collection is inline and small:Leave
assertas-is when:assert(rg.compare("3", "18") < 0))D_==that isn't a simple equality checkBuild Commands